
\chapter{通用的服务端框架的设计与实现}{}


\section{背景}{}
通常情况下，一个完整的短连接请求的全过程分为四步（以一次Http协议请求为例）：

\begin{figure}[h]
    \centering
    \includegraphics[scale=0.55]{figures/one-thread-activity-diagrame.pdf}
    \caption{一个Http短链接请求的活动图}
    \label{fig:one-thread-activity-diagrame}
\end{figure}

\begin{enumerate}
	\item 服务端接收客户主机发来的连接。具体过程为：为其在服务端进程中打开一个文件描述符，用以标定一个全局唯一的TCP连接，TCP连接以一个四元组——源IP地址、源端口、目的IP地址与目的端口来唯一标识。
	\item 服务端进程解析第一步TCP连接中的客户发送的字节流。具体过程为：进程从操作系统维护的文件描述符对应的内存缓冲区中读取客户发来的TCP字节流，并且根据提前约定好的应用层协议进行解析，解析完毕后服务端进程应该判断出此连接是否是一个合法的连接。若合法，获取应用层协议包中的结构（如客户端请求的url，请求参数等），否则认为请求不合法直接关闭此连接。
	\item 服务端根据第二步骤解析得到的应用层包体数据，派发给对应的上层业务处理类完成相应的业务处理。
	\item 最后将处理结果根据约定好的应用层协议打包后序列化为TCP字节流，发送回客户主机，最后关闭此文件描述符。
\end{enumerate}

上述四个过程的资源消耗情况有如下特点：
\begin{enumerate}
	\item 过程一中建立连接，由于本质上就是打开一个文件描述符并且向内核中epoll维护的红黑树进行插入注册，消耗的时间、空间相比与过程二进行读取文件描述符的数据并且解析要小很多。
	\item 过程二解析字节流数据，相比于过程三进行业务逻辑，在时间、空间上的消耗是可预测的。过程三中可能出现某些上层业务需要消耗较多的时间空间。
	\item 过程四消耗的时间空间资源也可预测。实际仅完成将过程三中上层业务处理后的结果写入到第一步中打开的文件描述符的内存缓存区。由于关闭文件描述符的操作也很轻量，因此本过程和过程二耗费的代价类似。
\end{enumerate}



\section{模型概况}{}

\begin{figure}[h]
  \centering
  \includegraphics[scale=0.5]{figures/model-overview.pdf}
  \caption{单个服务端进程的框架模型}
  \label{fig:model-overview}
\end{figure}

单个进程采用网络和业务分离的模式：主线程接收连接、网络线程完成I/O、工作线程完成业务。多个同样的进程监听着不同的端口，使用反向代理作为网关，按照一定的规则转发请求到各个进程\textsuperscript{\cite{赵凯2020一种基于}}。

主线程只监听与进程绑定端口的文件描述符，监听新的连接后，使用哈希算法平均派发给所有的网络线程，具体而言，所谓派发指将此连接的文件描述符添加进网络线程的epoll兴趣集的红黑树中。

网络线程和与自己绑定的一个或多个工作线程使用中间件来相互通信\textsuperscript{\cite{曹婧华2010分布式消息队列的设计与实现}}。中间件包含共享两个线程安全队列：接受队列保存所有等待工作线程处理的协议包，发送队列保存了等待网络线程发送回客户连接的协议包。此种中间件的通信方式参考了开源的rpc框架Tars的实现\textsuperscript{\cite{tars}}。


\begin{figure}[h]
  \centering
  \includegraphics[scale=0.45]{figures/model-run.pdf}
  \caption{一个服务端进程内各种线程的通信方式}
  \label{fig:model-run}
\end{figure}

网络线程具有两个任务，简单地说即接收数据时解析和发送数据时打包。接收数据时，epoll接收内核返回的网络事件，读取对应的TCP连接的字节流，解析并判断是否合法连接，合法则将解析后的包体添加到线程安全的接收队列中，并以哈希算法派发给一个工作线程，通知其完成处理。发送数据时，若接受到来自工作线程的处理完毕的通知，将去处理发送队列中的所有待发送的协议包。

工作线程负责完成上层的业务逻辑处理。它始终阻塞等待于接收队列中等待新包体的到来。处理完成后将响应体添加到发送消息队列中，最后通知网络线程完成发送。


\section{底层设计}{}
本文提出这样的模型不是一开始就得出的，它是实现过程中因为一个个问题的出现又解决，渐渐调整后的得到的相对最优结果。

首先最显著的特征就是将各个线程分类了。本文不把所有的逻辑放到多个同样的线程，这样虽然实现容易，但容易出现错误不易维护\textsuperscript{\cite{孙卫琴2004Tomcat}}。一个最典型的缺点是：建立网络连接消耗的时间与内存相对后续的逻辑通常是很小的，因此在一个线程处理耗时较长的业务逻辑的过程中，若出现新的客户连接请求，也无法分出极少部分的cpu时间去建立新的连接。这种情况在客户量比较多的时候是难以接受的，因为请求的第一步——建立连接的过程就处于长时间无响应的状态，客户甚至不知道此时服务器是处于宕机状态还是其他异常状态，图\ref{fig:one-thread-flowchart}展示了这种典型的缺点。


\begin{figure}[h]
  \centering
  \includegraphics[scale=0.45]{figures/one-thread-flowchart.pdf}
  \caption{若将所有的逻辑用同一种通用的线程来完成}
  \label{fig:one-thread-flowchart}
\end{figure}


因此，本文将处理新连接的逻辑独立出来，单独作为一个线程运行。由于这是个I/O密集型任务（大部分时间是在等待着监听描述符的I/O事件，即新连接的到来，且只有插入新连接到epoll兴趣集需要占用cpu时间），因此大部分时间内，此线程都处在cpu调度的等待队列，而不在就绪队列中参与cpu调度，故几乎不会因为新增线程而增加过多的额外开销。

	\subsection{与上层业务完全解耦以保护底层框架}{}
经过一段时间的运行以及日志分析后，本文发现：不同的上层业务的耗时是不同的，一些稍微轻量的任务耗时可能与解析TCP字节流的耗时相近甚至更短，而一些需要大量I/O的上层业务，比如需要读大量的文件，或需要查询大量数据库信息，其耗时可能远远大于其他步骤。由于其他步骤与上层业务处理是处于同一个线程中，因此我们有理由认为：每个模块消耗的时长的比例，就是这个模块所分得的cpu时间片的比例，进而就是他获得的计算资源的比例。考虑某个上层业务消耗的时间占到极高的比例时（如95\%以上），那么其他的所有模块的正常工作就可能因此影响——解析TCP字节流到应用层协议、打包应用层协议的回包等任务只分得极少的cpu时间。换言之，较慢速的上层业务影响到了底层的网络数据解析部分。

一个更严重的问题是，在底层通用框架的设计实现中，是无法得知将来的上层业务逻辑的具体实现，且这个工作很有可能是由第三方去完成。我们无法预先得知它们的耗时情况、资源消耗情况、是否出现致命的bug等情况。本着软件工程中极其重要的解耦原则，我们将上层业务的执行也独立开来，作为单独的线程（工作线程）来运行。凭借这样的设计，框架能避开许多潜在的隐患：

\begin{enumerate}
	\item 上层业务耗时异常得长时，由于分给的线程数量是一定的（或是有一个指定的上限），因此限制了上层所分到的最大cpu计算资源，这保证了底层框架持有的cpu计算资源下限，使得底层的通用框架不受第三者的影响。
	\item 上层业务程序员由于程序设计不佳而出现死锁时，若还是采取不拆分线程的做法，死锁将会宕掉整个服务——新的客户无法接入连接了、已有客户的网络数据也无法被解析了。而拆分上层业务到工作线程后，死锁只会宕掉工作线程本身，而底层框架依然正常运作不受影响。当框架发现工作线程的正常工作停止后，底层依然有能力做善后工作——可以向中心节点报警、可以主动结束工作线程并尝试重新拉起等。
\end{enumerate}

值得注意的是，将工作线程独立出来后，处理请求的总速度与原来相比是保持不变的，总速度总是决定于最慢的那一个过程。此处使用图\ref{fig:coorperate}作为一个便于读者理解的实例进行分析。

\begin{figure}[h]
  \centering
  \includegraphics[scale=0.45]{figures/coorperate-activity.pdf}
  \caption{合理分配网络线程与工作线程后的活动图}
  \label{fig:coorperate}
\end{figure}

本文假设网络数据解析耗时250ms，上层业务耗时750ms，原来有4个通用的线程，即一个通用线程完成所有的工作。一秒内，处理的请求个数是$4 * 1s / (250ms + 750ms) = 4$个。独立后，设一个线程用于解析网络数据，三个线程用于处理上层业务。一秒内，解析了$1*1s / 250ms = 4$个请求的网络数据，处理了$3 * 1s / 750ms = 4$个请求的上层业务。由此可见，在同样多的线程数量的前提下，系统总吞吐量是不变的。

	\subsection{底层框架迁就上层业务以高效配合}{}
上段的做法都体现着一个思想——底层不完全信任上层业务，需要将底层框架与上层充分隔离开来以保护底层自身的完美运行，而本文也确实是这样做的。但从另一个角度考虑，正常运作的情况下，底层与上层是共同组合而成的一个整体，它们合作着完成了整个服务。因此，我们需要保证二者合作的高效性。在上一段的例子中，我们将工作线程的数量设置为了网络线程数量的3倍，这是基于一个假设：在正常工作的前提下，一个上层业务的处理时间是网络数据解析时间的3倍。换言之，在同一个时间单元内，一个网络线程能完成的请求个数是一个工作线程了3倍，为了让二者的处理能力相匹配，就得将它们的数量之比保持为速度的反比。这样也就保证了二者通信的中间件，即接收队列中不存在积压。这个比例可能是一个先验的经验值，需要提前设定在配置文件中，也可能是个动态配置的值——底层框架通过观察一定时间内二者的耗时情况来设定，或是通过判断接收队列的积压情况来动态改变。

不过，无论使用哪一种方式来设定工作线程的数量，本文的参考因素都是上层业务与底层的耗时关系，有一点是不可规避的，即这个比例需要有一个上限值，即我们不可能为了配合二者的工作而完全地、无限制地迁就上层。这个上限值应当受到两个因素的影响，第一点已经在上文中有提及到，即我们需要保证底层框架必须分配到至少大于某一个最低比例的计算资源来保证它的正常工作。第二点有关于操作系统进程中的线程调度。在执行完全的cpu密集型的任务时，欲达到最高效率，分配线程的数量应当恰好等于cpu的核心数（Intel超线程技术除外，它应当是核心数的两倍\textsuperscript{\cite{2003Exploring}}），过多的线程数量除了会增大上下文切换的开销（尽管切换线程只用切换通用寄存器、栈寄存器等的值而不用切换页表、TLB等进程级资源，但不可否认这都是不必要引入的额外开销）之外别无益处\textsuperscript{\cite{孙菲艳2018Linux}}。但事实上，上层业务不可能是完全的cpu密集型的任务，因此最佳的总线程数量也不应直接取cpu的核心数，它也是由具体业务的I/O占比情况而定的、围绕在cpu核心数之上一定范围内的经验值。

后期，在处理I/O密集型的上层业务时，我们将使用协程的方式来取代线程切换\textsuperscript{\cite{李中跃2012一种低成本的}}。相比与线程切换，协程通过让出当前cpu的方式，修改程序计数器跳转到其他的指令序列入口，直接在用户层执行上下文切换，具有更小的上下文切换代价\textsuperscript{\cite{刘书健2016基于协程的高并发的分析与研究}}。

\subsection{过载保护}{}
在某些I/O繁重的上层业务中，我们将工作线程数量分配到上一节提到的上限之后，仍然可能遇到业务堆积的情况，即上层业务相对于底层的网络收发是性能瓶颈的情况。这种情况下，若不对待处理的业务量做一个限制，就可能导致大量的客户遇到延迟过大的情况甚至服务端进程接收队列过长导致崩溃\textsuperscript{\cite{邓珍荣2013一种}}。

因此，本文为每个网络线程以及其配对的工作线程设定了一个积压阈值，超过此积压阈值后，认为本网络线程处于过载状态。新连接的客户将被快速拒绝访问，即不对其完成对应的上层业务而直接返回提示信息，如指定的错误码。一个另外的收益是，当网络线程检测到过载后，还能及时通过心跳包向后文中的负载均衡节点发送通知\textsuperscript{\cite{2021Software}}，使其降低对自己的任务分配量。


\section{上层业务管理}{}
上层业务的数据结构形式多样、结构复杂。在七层网络模型的应用层之上再封装一层大大降低了管理繁杂的上层业务的难度。

	\subsection{结构化数据序列化方式}{}
此前有研究分别从数据传输开销、传输时间以及客户端数据反序列化效率这三个方面对json、XML及FSV（fixed-sizedfile）进行量化比较，实验数据表明，json在数据传输效率方面明显优于其他数据传输格式，这为轻量级应用中数据传输格式的选择提供了一个优化的参考方案\textsuperscript{\cite{json_perf}}。

但本文采用了google开发的Protobuf的方式代替了传统的序列化方式json、xml等。本小节完成的实验证明了Protobuf性能相对json的优越性。json由于传递的必须是可打印的字符（ASCII码等），一般使用base64进行编码。将任意的二进制字节全部转化为可打印的编码，由于同一个字节能表示的范围降低了，因此编码后的长度必定增加。下面的实验使用的结构化数据如图\ref{fig:protofile}所示，使用的具体实验数据如图\ref{fig:protodata}所示。实验结果如表\ref{tab:pb-perf}所示。


\begin{figure}[h!]
  \centering
  \subfloat[定义的结构化数据的结构（此处使用ProtoBuf的语法表示，语法类似于C++，Java。）] {
      \includegraphics[width=6.67cm]{figures/protofile.jpeg}
      \label{fig:protofile}}
  \subfloat[进行序列化耗时对比实验的具体数据。] {
      \includegraphics[width=6.67cm]{figures/protodata.png}
      \label{fig:protodata}}
  \caption{结构化数据序列化实验数据格式}
\end{figure}


\begin{table}[h]
    \caption{\label{tab:pb-perf}使用protobuf和json去序列化同一个数据类型10\textsuperscript{4}轮的耗时情况（ms）。实验数据产生于以下实验环境环境：python3.8.2，python的json库，硬件为MacBook Pro (16-inch, 2019)。}
    \centering
    \begin{tabular}{c c c c c c}
        \toprule
           序列化方式    & 序列化 & 反序列化 & 序列化反序列化交错进行    \\
        \midrule
           ProtoBuf        & 0.01596    & 0.01359      & 0.03035    \\
           Json  	        & 0.03198    & 0.03057      & 0.08212   \\
        \bottomrule
    \end{tabular}
\end{table}

从上表数据大致看出使用Protobuf后，客户端和服务端在进行数据解包和打包时，速度均提升了$2 \sim 3$倍。

	\subsection{接口组织形式}{}
一个通用的网络接口的数据形式如图\ref{fig:basereqresp}所示，由于制定通用的数据格式时不知道具体业务的数据形式，兼顾到protobuf的特性，此处使用了一个bytes来为上层数据做预留。打包时，框架只用将上层业务首先序列化为字节流，然后作为一个独立字段，与几个通用的字段一起打包即可。通用的字段包括：
\begin{enumerate}
	\item 全局唯一的网络接口id，使用这一个全局id来代替传统的url来定位资源。使用这样的方式不用单独去申请Https证书\textsuperscript{\cite{刘景云2014使用}}也能完成加密。传统的Url由于是处于Http的请求行中，明文出现在Http报文中，这增大了被攻击的风险。使用id代替Url，打包到请求体后，可使用加密算法对包体进行加密。攻击者抓包后虽然看到的是明文的Http请求包，却无法理解包体的含义，且仅从未加密的请求行和请求头中无法获取任何有用的信息。
	\item 错误代码，服务端与客户端提前约定的错误代码。
	\item 错误信息，人类可读的错误信息，便于调试。
\end{enumerate}

值得注意的是，使用接口id来代替url并加密的方式，仍无法抵挡来自传输层的攻击，如DoS攻击\textsuperscript{\cite{张明雷2012无线局域网中}}。我们只能从一些处于更低层级的方式如检测TCP分组的参数来避免这类攻击\textsuperscript{\cite{dos_attack}}。

\begin{figure}[h!]
  \centering
  \subfloat[请求包体的protobuf格式。]{
      \includegraphics[width=6.67cm]{figures/basereq.jpeg}
      \label{fig:basereq}}
  \subfloat[响应包体的protobuf格式。]{
      \includegraphics[width=6.67cm]{figures/baseresp.jpeg}
      \label{fig:baseresp}}
  \caption{业务无关的通用消息体格式}
  \label{fig:basereqresp}
\end{figure}

\section{应用层协议}{}
本文实现了Http1.1协议的解析与序列化，并将其作为短链接应用层协议，后续增加实现了WebSocket协议的解析与序列化，作为长链接协议。

在WebSocket出现之前，为了实现消息实时更新或推送，一般采用轮询技术。轮询是指在特定的的时间间隔，由客户对服务器发出Http请求，然后由服务器返回最新的数据给客户端。这种传统的模式带来很明显的缺点：

\begin{enumerate}
    \item 客户端需要不断的向服务器发出请求，然而Http请求可能包含较长的头部，其中真正有效的数据可能只是很小的一部分，显然这样会浪费很多的带宽等资源。
    \item 服务器上的最新消息只有等到客户询问了之后才能被获取，具有一定的时延。
    \item 若服务器上没有需要更新的消息，客户不停地做轮询将不断浪费网络带宽等资源。
\end{enumerate}

WebSocket是在单个TCP连接上进行全双工通讯的协议，允许服务端主动向客户推送数据。客户与服务端只需完成一次握手，两者之间就可以维持持久性的连接，并进行双向的数据传输\textsuperscript{\cite{温照松2012基于}}。

虽然WebSocket是全双工协议，但这并不代表其是个技术革新，因为其下层的TCP协议就已经实现了全双工通讯，我们也能在TCP协议之上轻易地封装自己的全双工应用层协议，只是由于Http协议设计的局限性使得其只能完成客户端主动请求数据的单工通讯方式。


\section{反向代理模块的设计与实现}{}
	\subsection{概况}{}
反向代理与正向代理都属于代理服务器，反向代理工作在服务端，而正向代理工作在客户端。反向代理会根据客户的请求以及各个服务器的状态，从其关系到的服务器集群中挑选出最合适的一个来进行分配。客户端在整个过程中，只知道反向代理的存在，而对其后的服务器集群毫无感知。当前流行的CDN、SNI等，也都是反向代理的延伸应用。反向代理的主要功能\textsuperscript{\cite{nginx}}包括：
\begin{enumerate}
	\item 隐藏真实的服务器或者服务器集群的信息。
	\item 作为工作在应用层的防火墙，可对网络攻击提供一定量的拦截。
	\item 负载均衡，反向代理可以根据各个服务器当前的状态，动态得调整分配规则，将新的请求分配给负荷较低者。
	\item 为在私有网络（如局域网）下的服务器集群提供NAT穿透及外网发布服务。
\end{enumerate}

市面上已经有了许多成熟的反向代理，如Nginx。反向代理的工作模型如图\ref{fig:reverse-proxy}所示。

\begin{figure}[h]
  \centering
  \includegraphics[scale=0.45]{figures/reverseproxy.pdf}
  \caption{反向代理的工作模式}
  \label{fig:reverse-proxy}
\end{figure}

为了深入研究其中的原理与细节，本文自行实现一个反向代理服务器程序。

	\subsection{实现原理}{}
	        \subsubsection{转发请求}{}
首先是基础模块：转发请求。由于本文实现的反向代理工作在应用层，因此，也需要一个完整的处理传输层数据的组件。此处我们可直接复用上文中应用服务器的相关逻辑。同时，作为应用层代理，我们也需要完成防火墙的职责\textsuperscript{\cite{李璐2015Linux}}。对于应用层协议不符的请求，我们将直接进行丢弃处理。具体在转发时，与应用服务器节点建立TCP连接后，将通过防火墙检测的应用层协议包对应的字节流发送到应用服务器节点，后者在处理完成后将返回体回传到反向代理。为了提高反向代理的效率，反向代理在将请求转发到应用节点之后，不等待应用节点回传返回体（同步执行），而是采用异步形式：转发请求后，将以键值对的形式保存客户端连接id与应用节点的连接id，直接回到下一个请求的监听中。当监听到应用节点回传了协议包，再去查询与客户端的连接id，最终由反向代理完成协议包的回传。
	        \subsubsection{负载均衡}{}
负载均衡决定了代理将如何转发请求。一个高适用性的反向代理还应支持动态更新应用服务器的状态\textsuperscript{\cite{林丽丽2010使用高性能}}，本文根据需要在反向代理中存储各个应用服务器的状态信息，且增加了心跳机制。在反向代理启动之前，我们将提前已经知晓的应用服务器节点的信息存入到配置文件中，反向代理在启动时读取配置文件，以初始化应用服务器节点列表。应用服务器节点列表的更新有以下三种方式：
\begin{enumerate}
	\item 新的节点在启动的时候将主动得向负载均衡服务器注册自己。
	\item 应用节点启动后定期向反向代理发送心跳包以告知自己的存在与状态。
	\item 反向代理进行转发时若发现宕掉的服务器将会更新其状态。
\end{enumerate}
基于上述的三种方式，本文可以保证在反向代理服务器正常运行的前提下，一个应用节点意外宕机将无从影响整个服务系统的运行\textsuperscript{\cite{李彬2013Nginx}}。

本文实现了三种负载均衡的策略：
\begin{enumerate}
	\item 简单的轮询机制。这种机制最易于实现，但缺点明显：它没有关注到任何给定节点的信息。因此只能做分摊任务，而无法做到真正的均衡。
	\item 基于权重与状态的均衡。管理者根据硬件性能等，提前为应用服务器节点设定好权重。并且节点在每次向反向代理发送的心跳包时，将带上自己的工作线程总积压量。反向代理根据这两个信息，确定了每个节点当前的总权重，权重越大，被分配到新请求的几率越大。
	\item 基于IP地址的哈希算法的均衡\textsuperscript{\cite{曹道林2015基于动态规划和}}。上述方式均有如下缺点：若一个客户端的两次请求期待分配到同一个服务节点进行处理，它们将无法满足（不过这依然可使用额外的机制如远程过程调用来解决）。基于客户的IP进行哈希算法计算后，能保证同一个IP地址最终分配到一个主机，这对于两次不同的请求可以使用缓存来加速的情景下有较高的性能提升。
\end{enumerate}

